Repository navigation
Conversation
The global SWIG nothreadallow feature overrides threading support. Limit GIL release to pipeline updates and reacquire it for callbacks. Replace setup-time overlap detection with a native execution check. Fixes InsightSoftwareConsortium#7000
[High impact] The change affects pipeline updates across ITK’s Python wrappers. Fix the unchecked float requirement in Findings
|
Custom wrapping builds may disable float while retaining other scalar types. Exercise an available two-dimensional specialization instead.
|
The Linux failure looks separate from this fix: filtering succeeds, then the median test’s type-hint check raises All three GIL-related tests passed in that job. The float-wrapping review finding is fixed in 2d8272e; those tests also pass locally. |
Observers can execute while pipeline updates have released the GIL. Protect callable validation as well as invocation and error handling.
hjmjohnson
left a comment
There was a problem hiding this comment.
@sdiebolt Thanks, this is a careful fix, and I reproduced it locally (results below). The setswitchinterval probe is a real improvement: at the merge base the old overlap test passes while your new test fails with Update did not release the GIL.
Requested before merge:
-
Squash
2d8272e742candd0a09029620into0f6c5a10a70. Both fix something the first commit introduced: it switchesPyImageFilterTest.pytoitk.F, and releases the GIL inUpdate()whilePyCommandstill callsPyCallable_Checkwithout it. One commit keeps every revision bisectable. -
Two comments still describe the wider release (on lines this PR doesn't touch, so noted here):
Wrapping/Generators/Python/CMakeLists.txt:339: "release the GIL during ITK operations"Wrapping/Generators/Python/Tests/test_gil_release_safety.py:27: "SWIG wraps C++ calls in SaveThread/RestoreThread"
Only
UpdateandUpdateLargestPossibleRegionrelease it now. -
The GIL tests hard-code
itk.F(inline). Either guard them withITK_WRAP_floatin CMake or select a wrapped type. -
The unset-callable
PyCommandblock is an error-path test, not a regression test for the GIL fix (inline).
The END_BLOCK and duplicated-callback notes inline are optional cleanup.
Local verification (macOS arm64, CPython 3.13.9, NumPy 2.3.4, SWIG 4.4.1, abi3)
Minimal wrapped Release build (ITKSmoothing, ITKBridgeNumPy, ITK_PYTHON_RELEASE_GIL=ON), running this PR's three test files against each revision:
| Revision | test_gil_release.py |
test_gil_release_safety.py |
PyImageFilterTest.py |
|---|---|---|---|
merge base 5e1bd43 |
FAIL: Update did not release the GIL (the old overlap test passes here) |
16/16 | pass |
2d8272e742c (before the PyCommand fix) |
pass | 16/16 | pass |
d0a09029620 (head) |
pass, 20/20 repeats on a loaded host | 16/16 | pass |
Not run: a build with ITK_WRAP_float=OFF, and a build with ITK_PYTHON_RELEASE_GIL=OFF.
Checked against the code
igenerator.py:1565-1570dropsthreads="1", keeps the global%feature("nothreadallow"), and adds%threadallow Update;and%threadallow UpdateLargestPossibleRegion;. That answers #7000's question aboutnothreadallowinteracting with-threads.ITK_PYTHON_RELEASE_GIL=OFFnow really turns the release off.- C++ exceptions retake the GIL before SWIG converts them to Python errors: SWIG's thread-allow object lives inside
$action, within thetryinpyBase.i. itk.imread,itk.imwrite, and the functional filter calls all go through the patched methods.
Not requested here: UpdateOutputInformation, ImageFileWriter::Write(), and optimizer StartOptimization() still hold the GIL, which matches #7000 and the migration guide. Wider coverage belongs with the thread-safety audit in #6757.
| itkExceptionStringMacro("There was an error executing the CommandCallable."); | ||
| } | ||
| } | ||
| SWIG_PYTHON_THREAD_END_BLOCK; |
There was a problem hiding this comment.
The SWIG_PYTHON_THREAD_END_BLOCK lines here (and at 206, 237, 272) are redundant. SWIG_PYTHON_THREAD_BEGIN_BLOCK declares a SWIG_Python_Thread_Block object whose destructor calls end(), and END_BLOCK only calls end() early. Each one is the last statement before the closing brace, so the GIL is released at the same point either way. The throw paths skip END_BLOCK already and rely on the destructor, so dropping all four changes nothing.
These four methods now repeat the same "call, decref, PyErr_Print, throw" sequence. A private helper would remove the duplication and give each caller a single GIL scope:
template <class TInputImage, class TOutputImage>
void
PyImageFilter<TInputImage, TOutputImage>::CallPython(PyObject * callable, PyObject * args) const
{
PyObject * result = PyObject_Call(callable, args, nullptr);
SWIG_Py_DECREF(args);
if (result == nullptr)
{
PyErr_Print();
itkExceptionStringMacro("There was an error executing the CommandCallable.");
}
SWIG_Py_DECREF(result);
}
template <class TInputImage, class TOutputImage>
void
PyImageFilter<TInputImage, TOutputImage>::GenerateOutputInformation()
{
Superclass::GenerateOutputInformation();
SWIG_PYTHON_THREAD_BEGIN_BLOCK;
if (PyCallable_Check(this->m_GenerateOutputInformationCallable))
{
this->CallPython(this->m_GenerateOutputInformationCallable, PyTuple_Pack(1, this->m_Self));
}
}In EnlargeOutputRequestedRegion, decref pyDataObject right after PyTuple_Pack(2, ...) (the tuple holds its own reference), so the throw path doesn't leak it.
| median.AddObserver(itk.StartEvent(), itk.PyCommand.New()) | ||
| try: | ||
| median.Update() | ||
| check("unset observer callable rejected", False, "should have raised") | ||
| except RuntimeError as error: | ||
| check("unset observer callable rejected", "CommandCallable" in str(error)) |
There was a problem hiding this comment.
This is a good error-path check, but it can't detect the defect d0a09029620 fixes. With no callable set, m_Object is NULL, and PyCallable_Check(NULL) returns 0 without touching interpreter state, so the block passes whether or not the GIL is held. I confirmed this locally: at 2d8272e742c (GIL released, before this fix) the safety script passes 16/16, including this block.
I don't think a release-build test can catch a missing GIL before PyCallable_Check; the fix is clearest by reading the code. Could the commit message and PR body describe this block as an error-path test rather than a regression test for the GIL fix?
| # itk.imread on a non-existent file throws RuntimeError through the | ||
| # SWIG wrapper. With -threads, the GIL is released before the C++ call | ||
| # and must be re-acquired before setting the Python exception. | ||
| image_type = itk.Image[itk.F, 2] |
There was a problem hiding this comment.
Nothing in these GIL tests needs float: they run a median filter on a constant image and check an exception for a missing input. Any wrapped scalar pixel type works. make_image() (line 101) and test_gil_release.py:8 also hard-code itk.F, so builds with ITK_WRAP_float=OFF can't run them, even though PyImageFilterTest.py was fixed for the same reason in 2d8272e742c.
Either fix is fine:
- Register the GIL tests only when float is wrapped, as
PythonGeometryProtocolTestalready does (Tests/CMakeLists.txt:190,if(ITK_WRAP_float AND WRAP_3)). This is the smallest change. - Reuse the pixel-type selection from
PyImageFilterTest.pyin both GIL test files.
| COMMAND | ||
| ${CMAKE_CURRENT_SOURCE_DIR}/test_gil_release.py | ||
| ) | ||
| if(ITK_PYTHON_RELEASE_GIL) |
There was a problem hiding this comment.
If you take the CMake route for the float dependency, one block can guard both GIL tests. The safety test then needs ITK_WRAP_float only, since it is meant to pass with ITK_PYTHON_RELEASE_GIL on or off:
if(ITK_WRAP_float)
if(ITK_PYTHON_RELEASE_GIL)
itk_python_add_test(NAME PythonGILReleaseTest ...)
endif()
itk_python_add_test(NAME PythonGILReleaseSafetyTest ...)
endif()
Let Python threads run during ITK pipeline updates, and make the regression test check filtering rather than setup. Closes #7000.
Why the GIL stayed locked
The 2019 GIL-release change enabled SWIG threading but also set
nothreadallow, citing this SWIG discussion. That advice served a different goal: protecting director callbacks while deliberately avoiding GIL release around native calls. The combination therefore did not provide the concurrency described by the commit.The later
-threadsaddition in #5776/#6022 left that override in place. Its regression test accepted overlapping setup/filtering intervals as evidence of GIL release. Measured intervals were about 0.81 seconds, but only 0.002–0.003 seconds were insideUpdate().What this fixes
Update()andUpdateLargestPossibleRegion(), retaining it for unrelated wrappers and respectingITK_PYTHON_RELEASE_GIL.PyImageFiltercallbacks and beforePyCommandvalidates observer callables.Update()exceptions instead of errors that could arise in Python-side file-reading setup.Validation
Linux x86_64, CPython 3.13.9, NumPy 2.3.4, Release build:
Update()appears to hold the GIL despite GIL-release support #7000's ticker workload: 913–927 ticks during 0.97–0.98 seconds of filtering.PyCallable_Check()without the GIL before thePyCommandfix; the probe and full safety script pass after it.The initial Linux CI run also passed all three targeted tests. Its separate median type-hint failure is described in the PR comments.
Full local suite, Python 3.14 wheel, and full GIL-disabled native build validation were not run. CI must validate the latest revision.
PR Checklist
AI assistance
AI assistance supported diagnosis, implementation, and local testing.